<?php



/*****************************************************************
* Make the request
* This request object uses custom header/body reading functions
* so we can start processing responses on the fly - e.g. we don't
* need to wait till the whole file has downloaded before deciding
* if it needs parsing or can be sent out unchanged.
******************************************************************/

class Custom_Glype_Request
{

   // Response status code
   public $status = 0;

   // Headers received and read by our callback
   public $headers = array();

   // Returned data (if saved)
   public $return;

   // Reason for aborting transfer (or empty to continue downloading)
   public $abort;

   // The error (if any) returned by curl_error()
   public $error;

   // Type of resource downloaded [html, js, css] or empty if no parsing needed
   public $parseType;

   // Automatically detect(ed) content type?
   public $sniff = false;

   // Save the downloaded file (if no $parseType) for the cache with this path
   private $saveAs = false;

   // File handle for cache file
   private $cacheHandle;

   // Forward cookies or not
   private $forwardCookies = false;

   // Limit filesize?
   private $limitFilesize = 0;

   // Speed limit (bytes per second)
   private $speedLimit = 0;

   // URL array split into pieces
   private $URL;

   // = $options from the global scope
   private $browsingOptions;

   // Options to pass to cURL
   private $curlOptions;


   // Constructor - takes the parameters and saves them
   public function __construct($curlOptions) {

      global $options, $CONFIG;

      // Set our reading callbacks
		$curlOptions[CURLOPT_HEADERFUNCTION] = array(&$this, 'readHeader');
		$curlOptions[CURLOPT_WRITEFUNCTION] = array(&$this, 'readBody');

      // Determine whether or not to forward cookies
      if ( $options['allowCookies'] && ! $CONFIG['cookies_on_server'] ) {
         $this->forwardCookies = $CONFIG['encode_cookies'] ? 'encode' : 'normal';
      }

      // Determine a filesize limit
      if ( $CONFIG['max_filesize'] ) {
         $this->limitFilesize = $CONFIG['max_filesize'];
      }

      // Determine speed limit
      if ( $CONFIG['download_speed_limit'] ) {
         $this->speedLimit = $CONFIG['download_speed_limit'];
      }

      // Set options
      $this->browsingOptions = $options;
      $this->curlOptions = $curlOptions;

      // Extend the PHP timeout
      if ( ! SAFE_MODE ) {
         set_time_limit($CONFIG['transfer_timeout']);
      }

      // Record debug information
      if ( DEBUG_MODE ) {
         $this->cookiesSent = isset($curlOptions[CURLOPT_COOKIE]) ? $curlOptions[CURLOPT_COOKIE] : ( isset($curlOptions[CURLOPT_COOKIEFILE]) ? 'using cookie jar' : 'none');
         $this->postSent = isset($curlOptions[CURLOPT_POSTFIELDS]) ? $curlOptions[CURLOPT_POSTFIELDS] : '';
      }

   }

   // Save our cache filename
   public function saveCache($as) {
      $this->saveAs = $as;
   }

   // Make the request and return the downloaded file if parsing is needed
   public function go($URL) {

      // Save options
      $this->URL = $URL;

      // Get a cURL handle
      $ch = curl_init($this->URL['href']);

      // Set the options
      curl_setopt_array($ch, $this->curlOptions);

      // Make the request
      curl_exec($ch);

      // Save any errors (but not if we caused the error by aborting!)
      if ( ! $this->abort ) {
         $this->error = curl_error($ch);
      }

      // And close the curl handle
      curl_close($ch);

      // Close our cache file handle
      if ( $this->cacheHandle ) {

         fclose($this->cacheHandle);

         // This may have been forced to download with a hard refresh
         // so an existing cache file may exist. Delete it.
         if ( file_exists($this->saveAs) ) {
            unlink($this->saveAs);
         }

         // Now rename the temp name to the final name
         rename($this->saveAs . '.tmp', $this->saveAs);

      }

      // And return the document (will be empty if no parsing needed,
      // because everything else is outputted immediately)
      return $this->return;

   }


   /*****************************************************************
   * * * * * * * * * * Manage the RESPONSE * * * * * * * * * * * *
   ******************************************************************/


   /*****************************************************************
   * Read headers - receives headers line by line (cURL callback)
   ******************************************************************/

   public function readHeader($handle, $header) {

      // Extract the status code (can occur more than once if 100 continue)
      if ( $this->status == 0 || ( $this->status == 100 && ! strpos($header, ':') ) ) {
         $this->status = substr($header, 9, 3);
      }

      // Attempt to extract header name and value
      $parts = explode(':', $header, 2);

      // Did it split successfully? (i.e. was there a ":" in the header?)
      if ( isset($parts[1]) ) {

         // Header names are case insensitive
         $headerType = strtolower($parts[0]);

         // And header values will have trailing newlines and prevailing spaces
         $headerValue = trim($parts[1]);

         // Set any cookies
         if ( $headerType == 'set-cookie' && $this->forwardCookies ) {

            $this->setCookie($headerValue);

         }

         // Everything else, store as associative array
         $this->headers[$headerType] = $headerValue;

         // Do we want to forward this header? First list the headers we want:
         $toForward = array('last-modified',
                            'content-disposition',
                            'content-type',
                            'content-range',
                            'content-language',
                            'expires',
                            'cache-control',
                            'pragma');

         // And check for a match before forwarding the header.
         if ( in_array($headerType, $toForward) ) {
            header($header);
         }

      } else {

         // Either first header or last 'header' (more precisely, the 2 newlines
         // that indicate end of headers)

         // No ":", so save whole header. Also check for end of headers.
         if ( ( $this->headers[] = trim($header) ) == false ) {

            // Must be end of headers so process them before reading body
            $this->processHeaders();

            // And has that processing given us any reason to abort?
            if ( $this->abort ) {
               return -1;
            }

         }

      }

      // cURL needs us to return length of data read
      return strlen($header);

   }


   /*****************************************************************
   * Process headers after all received and before body is read
   ******************************************************************/

   private function processHeaders() {

      // Ensure we only run this function once
      static $runOnce;

      // Check for flag and if found, stop running function
      if ( isset($runOnce) ) {
         return;
      }

      // Set flag for next time
      $runOnce = true;

      // Send the appropriate status code
      header(' ', true, $this->status);

      // Find out if we want to abort the transfer
      switch ( true ) {

         // Redirection
         case isset($this->headers['location']):

            $this->abort = 'redirect';

            return;

         // 304 Not Modified
         case $this->status == 304:

            $this->abort = 'not_modified';

            return;

         // 401 Auth required
         case $this->status == 401:

            $this->abort = 'auth_required';

            return;

         // Error code (>=400)
         case $this->status >= 400:

            $this->abort = 'http_status_error';

            return;

         // Check for a content-length above the filesize limit
         case isset($this->headers['content-length']) && $this->limitFilesize && $this->headers['content-length'] > $this->limitFilesize:

            $this->abort = 'filesize_limit';

            return;

      }

      // Still here? No need to abort so next we determine parsing mechanism to use (if any)
      if ( isset($this->headers['content-type']) ) {

         // Define content-type to parser type relations
         $types = array('text/javascript'          => 'javascript',
                        'application/javascript'   => 'javascript',
                        'application/x-javascript' => 'javascript',
                        'application/xhtml+xml'    => 'html',
                        'text/html'                => 'html',
                        'text/css'                 => 'css');

         // Extract mimetype from charset (if exists)
         list($mime) = explode(';', $this->headers['content-type'], 2);

         // Remove whitespace
         $mime = trim($mime);

         // Look for that mimetype in our array to find the parsing mechanism needed
         if ( isset($types[$mime]) ) {
            $this->parseType = $types[$mime];
         }

      } else {

         // Tell our read body function to 'sniff' the data to determine type
         $this->sniff = true;

      }

      // If no content-disposition sent, send one with the correct filename
      if ( ! isset($this->headers['content-disposition']) && $this->URL['filename'] ) {
         header('Content-Disposition: filename="' . $this->URL['filename'] . '"');
      }

      // If filesize limit exists, content-length received and we're still here, the
      // content-length is OK. If we assume the content-length is accurate (and since
      // clients [and possibly libcurl too] stop downloading after reaching the limit,
      // it's probably safe to assume that),we can save on load by not checking the
      // limit with each chunk received.
      if ( $this->limitFilesize && isset($this->headers['content-length']) ) {
         $this->limitFilesize = 0;
      }

   }


   /*****************************************************************
   * Read body - takes chunks of data (cURL callback)
   ******************************************************************/

   public function readBody($handle, $data) {

      // Static var to tell us if this function has been run before
      static $first;

      // Check for set variable
      if ( ! isset($first) ) {

         // Run the pre-body code
         $this->firstBody($data);

         // Set the variable so we don't run this code again
         $first = false;

      }

      // Find length of data
      $length = strlen($data);

      // Limit speed to X bytes/second
      if ( $this->speedLimit ) {

         // Limit download speed
         // Speed     = Amount of data / Time
         // [bytes/s] = [bytes]        / [s]
         // We know the desired speed (defined earlier in bytes per second)
         // and we know the number of bytes we've received. Now we need to find
         // the time that it should take to receive those bytes.
         $time = $length / $this->speedLimit; // [s]

         // Convert time to microseconds and sleep for that value
         usleep(round($time * 1000000));

      }

      // Monitor length if desired
      if ( $this->limitFilesize ) {

         // Set up a static downloaded-bytes value
         static $downloadedBytes;

         if ( ! isset($downloadedBytes) ) {
            $downloadedBytes = 0;
         }

         // Add length to downloadedBytes
         $downloadedBytes += $length;

         // Is downloadedBytes over the limit?
         if ( $downloadedBytes > $this->limitFilesize ) {

            // Set the abort variable and return -1 (so cURL aborts)
            $this->abort = 'filesize_limit';
            return -1;

         }

      }

      // If parsing is required, save as $return
      if ( $this->parseType ) {

         $this->return .= $data;

      } else {

         // No parsing so print immediately
         echo $data;

         // And add to cache if applicable
         if ( $this->cacheHandle ) {
            fwrite($this->cacheHandle, $data);
         }

      }

      // cURL needs us to return length of data read
      return $length;

   }




   /*****************************************************************
   * Process first chunk of data in body
   * Sniff the content if no content-type was sent and create the file
   * handle if caching this.
   ******************************************************************/

   private function firstBody($data) {

      // Do we want to sniff the data? Determines if ascii or binary.
      if ( $this->sniff ) {

         // Take a sample of 100 chars chosen at random
         $length = strlen($data);
         $sample = $length < 150 ? $data : substr($data, rand(0, $length-100), 100);

         // Assume ASCII if more than 95% of bytes are "normal" text characters
         if ( strlen(preg_replace('#[^A-Z0-9!"�$%\^&*\(\)=+\\\\|\[\]\{\};:\\\'@\#~,.<>/?-]#i', '', $sample)) > 95 ) {

            // To do: expand this to detect if html/js/css
            $this->parseType = 'html';

         }

      }

      // Now we know if parsing is required, we can forward content-length
      if ( ! $this->parseType && isset($this->headers['content-length']) ) {
         header('Content-Length: ' . $this->headers['content-length']);
      }

      // Create a file handle for the cache if required
      if ( $this->saveAs && ! $this->parseType ) {

         // Prepare a temporary name (help avoid concurrency issues)
         $tmp = $this->saveAs . '.tmp';

         // Check for existing temp file (i.e. download of this file in progress)
         if ( ! file_exists($tmp) ) {

            // Doesn't already exist so try to create it
            $this->cacheHandle = fopen($tmp, 'wb');

         }

      }

   }


   /*****************************************************************
   * Accept cookies - takes the value from Set-Cookie: [COOKIE STRING]
   * and forwards cookies to the client
   ******************************************************************/

   private function setCookie($cookieString) {

      // The script can handle cookies following the Netscape specification
      // (or close enough!) and supports "Max-Age" from RFC2109

      // Split parts by ;
      $cookieParts = explode(';', $cookieString);

      // Process each line
      foreach ( $cookieParts as $part ) {

         // Split attribute/value pairs by =
         $pair = explode('=', $part, 2);

         // Ensure we have a second part
         $pair[1] = isset($pair[1]) ? $pair[1] : '';

         // First pair must be name/cookie value
         if ( ! isset($cookieName) ) {

            // Name is first pair item, value is second
            $cookieName = $pair[0];
            $cookieValue = $pair[1];

            // Skip rest of loop and start processing attributes
            continue;

         }

         // If still here, must be an attribute (case-insensitive so lower it)
         $pair[0] = strtolower($pair[0]);

         // And save in array
         if ( $pair[1] ) {

            // We have a attribute/value pair so save as associative
            $attr[ltrim($pair[0])] = $pair[1];

         } else {

            // Not a pair, just a value
            $attr[] = $pair[0];

         }

      }

      // All cookies need to be sent to this script (and then we choose
      // the correct cookies to forward to the client) so the extra attributes
      // (path, domain, etc.) must be stored in the cookie itself

      // Cookies stored as c[domain.com][path][cookie_name] with values of
      // cookie_value;secure;
      // If encoded, cookie name becomes c[base64_encode(domain.com path cookie_name)]

      // Find the EXPIRES date
      if ( isset($attr['expires']) ) {

         // From the "Expires" attribute (original Netscape spec)
         $expires = strtotime($attr['expires']);

      } else if ( isset($attr['max-age']) ) {

         // From the "Max-Age" attribute (RFC2109)
         $expires = $_SERVER['REQUEST_TIME']+$attr['max-age'];

      } else {

         // Default to temp cookies
         $expires = 0;

      }

      // If temp cookies, override expiry date to end of session unless time
      // is in the past since that means the cookie should be deleted
      if ( $this->browsingOptions['tempCookies'] && $expires > $_SERVER['REQUEST_TIME'] ) {
         $expires = 0;
      }

      // Find the PATH. The spec says if none found, default to the current path.
      // Certain browsers default to the the root path so we'll do the same.
      if ( ! isset($attr['path']) ) {
         $attr['path'] = '/';
      }

      // Were we sent a DOMAIN?
      if ( isset($attr['domain']) ) {

         // Ensure it's valid and we can accept this cookie
         if ( stripos($attr['domain'], $this->URL['domain']) === false ) {

            // Our current domain does not match the specified domain
            // so we reject the cookie
            return;

         }

         // Some cookies will be sent with the domain starting with . as per RFC2109
         // The . then has to be stripped off by us when doing the tail match to determine
         // which cookies to send since ".glype.com" should match "glype.com". It's more
         // efficient to do any manipulations while forwarding cookies than on every request
         if ( $attr['domain'][0] == '.' ) {
            $attr['domain'] = substr($attr['domain'], 1);
         }

      } else {

         // No domain sent so use current domain
         $attr['domain'] = $this->URL['domain'];

      }

      // Check for SECURE cookie
      $sentSecure = in_array('secure', $attr);

      // Append "[SEC]" to cookie value if we should only forward to secure connections
      if ( $sentSecure ) {
         $cookieValue .= '!SEC';
      }

      // If we're on HTTPS, we can also send this cookie back as secure
      $secure = HTTPS && $sentSecure;

      // If the PHP version is recent enough, we can also forward the httponly flag
      $httponly = in_array('httponly', $attr) && version_compare(PHP_VERSION,'5.2.0','>=') ? true : false;

      // Prepare cookie name/value to save as
      $name = COOKIE_PREFIX . '[' . $attr['domain'] . '][' . $attr['path'] . '][' . inputEncode($cookieName) . ']';
      $value = $cookieValue;

      // Add encodings
      if ( $this->forwardCookies == 'encode' ) {

         $name = COOKIE_PREFIX . '[' . urlencode(base64_encode($attr['domain'] . ' ' . $attr['path'] . ' ' . urlencode($cookieName))) . ']';
         $value = base64_encode($value);

      }

      // Send cookie ...
      if ( $httponly ) {

         // ... with httponly flag
         setcookie($name, $value, $expires, '/', '', $secure, true);

      } else {

         // ... without httponly flag
         setcookie($name, $value, $expires, '/', '', $secure);

      }

      // And log if in debug mode
      if ( DEBUG_MODE ) {

         $this->cookiesReceived[] = array('name'         => $cookieName,
                                          'value'        => $cookieValue,
                                          'attributes'   => $attr);

      }

   }

}
